@@ -1,5 +1,6 @@ |
||
1 | 1 |
# Changes |
2 | 2 |
|
3 |
+* Jul 22, 2015 - DataOutputAgent can configure the order of events in the output via `events_order`. |
|
3 | 4 |
* Jul 20, 2015 - Control Links (used by the SchedularAgent) are correctly exported in Scenarios. |
4 | 5 |
* Jul 20, 2015 - keep\_events\_for was moved from days to seconds; Scenarios have a schema verison. |
5 | 6 |
* Jul 8, 2015 - DataOutputAgent supports feed icon, and a new template variable `events`. |
@@ -1,10 +1,8 @@ |
||
1 | 1 |
module DryRunnable |
2 |
- def dry_run! |
|
3 |
- readonly! |
|
2 |
+ extend ActiveSupport::Concern |
|
4 | 3 |
|
5 |
- class << self |
|
6 |
- prepend Sandbox |
|
7 |
- end |
|
4 |
+ def dry_run! |
|
5 |
+ @dry_run = true |
|
8 | 6 |
|
9 | 7 |
log = StringIO.new |
10 | 8 |
@dry_run_logger = Logger.new(log) |
@@ -14,6 +12,7 @@ module DryRunnable |
||
14 | 12 |
|
15 | 13 |
begin |
16 | 14 |
raise "#{short_type} does not support dry-run" unless can_dry_run? |
15 |
+ readonly! |
|
17 | 16 |
check |
18 | 17 |
rescue => e |
19 | 18 |
error "Exception during dry-run. #{e.message}: #{e.backtrace.join("\n")}" |
@@ -23,28 +22,38 @@ module DryRunnable |
||
23 | 22 |
memory: memory, |
24 | 23 |
log: log.string, |
25 | 24 |
) |
25 |
+ ensure |
|
26 |
+ @dry_run = false |
|
26 | 27 |
end |
27 | 28 |
|
28 | 29 |
def dry_run? |
29 |
- is_a? Sandbox |
|
30 |
+ !!@dry_run |
|
31 |
+ end |
|
32 |
+ |
|
33 |
+ included do |
|
34 |
+ prepend Wrapper |
|
30 | 35 |
end |
31 | 36 |
|
32 |
- module Sandbox |
|
37 |
+ module Wrapper |
|
33 | 38 |
attr_accessor :results |
34 | 39 |
|
35 | 40 |
def logger |
41 |
+ return super unless dry_run? |
|
36 | 42 |
@dry_run_logger |
37 | 43 |
end |
38 | 44 |
|
39 |
- def save |
|
40 |
- valid? |
|
45 |
+ def save(options = {}) |
|
46 |
+ return super unless dry_run? |
|
47 |
+ perform_validations(options) |
|
41 | 48 |
end |
42 | 49 |
|
43 |
- def save! |
|
44 |
- save or raise ActiveRecord::RecordNotSaved |
|
50 |
+ def save!(options = {}) |
|
51 |
+ return super unless dry_run? |
|
52 |
+ save(options) or raise_record_invalid |
|
45 | 53 |
end |
46 | 54 |
|
47 | 55 |
def log(message, options = {}) |
56 |
+ return super unless dry_run? |
|
48 | 57 |
case options[:level] || 3 |
49 | 58 |
when 0..2 |
50 | 59 |
sev = Logger::DEBUG |
@@ -57,10 +66,12 @@ module DryRunnable |
||
57 | 66 |
logger.log(sev, message) |
58 | 67 |
end |
59 | 68 |
|
60 |
- def create_event(event_hash) |
|
69 |
+ def create_event(event) |
|
70 |
+ return super unless dry_run? |
|
61 | 71 |
if can_create_events? |
62 |
- @dry_run_results[:events] << event_hash[:payload] |
|
63 |
- events.build({ user: user, expires_at: new_event_expiration_date }.merge(event_hash)) |
|
72 |
+ event = build_event(event) |
|
73 |
+ @dry_run_results[:events] << event.payload |
|
74 |
+ event |
|
64 | 75 |
else |
65 | 76 |
error "This Agent cannot create events!" |
66 | 77 |
end |
@@ -0,0 +1,183 @@ |
||
1 |
+module SortableEvents |
|
2 |
+ extend ActiveSupport::Concern |
|
3 |
+ |
|
4 |
+ included do |
|
5 |
+ validate :validate_events_order |
|
6 |
+ end |
|
7 |
+ |
|
8 |
+ def description_events_order(*args) |
|
9 |
+ self.class.description_events_order(*args) |
|
10 |
+ end |
|
11 |
+ |
|
12 |
+ module ClassMethods |
|
13 |
+ def can_order_created_events! |
|
14 |
+ raise if cannot_create_events? |
|
15 |
+ prepend AutomaticSorter |
|
16 |
+ end |
|
17 |
+ |
|
18 |
+ def can_order_created_events? |
|
19 |
+ include? AutomaticSorter |
|
20 |
+ end |
|
21 |
+ |
|
22 |
+ def cannot_order_created_events? |
|
23 |
+ !can_order_created_events? |
|
24 |
+ end |
|
25 |
+ |
|
26 |
+ def description_events_order(events = 'events created in each run') |
|
27 |
+ <<-MD.lstrip |
|
28 |
+ To specify the order of #{events}, set `events_order` to an array of sort keys, each of which looks like either `expression` or `[expression, type, descending]`, as described as follows: |
|
29 |
+ |
|
30 |
+ * _expression_ is a Liquid template to generate a string to be used as sort key. |
|
31 |
+ |
|
32 |
+ * _type_ (optional) is one of `string` (default), `number` and `time`, which specifies how to evaluate _expression_ for comparison. |
|
33 |
+ |
|
34 |
+ * _descending_ (optional) is a boolean value to determine if comparison should be done in descending (reverse) order, which defaults to `false`. |
|
35 |
+ |
|
36 |
+ Sort keys listed earlier take precedence over ones listed later. For example, if you want to sort articles by the date and then by the author, specify `[["{{date}}", "time"], "{{author}}"]`. |
|
37 |
+ |
|
38 |
+ Sorting is done stably, so even if all events have the same set of sort key values the original order is retained. Also, a special Liquid variable `_index_` is provided, which contains the zero-based index number of each event, which means you can exactly reverse the order of events by specifying `[["{{_index_}}", "number", true]]`. |
|
39 |
+ MD |
|
40 |
+ end |
|
41 |
+ end |
|
42 |
+ |
|
43 |
+ def can_order_created_events? |
|
44 |
+ self.class.can_order_created_events? |
|
45 |
+ end |
|
46 |
+ |
|
47 |
+ def cannot_order_created_events? |
|
48 |
+ self.class.cannot_order_created_events? |
|
49 |
+ end |
|
50 |
+ |
|
51 |
+ def events_order |
|
52 |
+ options['events_order'] |
|
53 |
+ end |
|
54 |
+ |
|
55 |
+ module AutomaticSorter |
|
56 |
+ def check |
|
57 |
+ return super unless events_order |
|
58 |
+ sorting_events do |
|
59 |
+ super |
|
60 |
+ end |
|
61 |
+ end |
|
62 |
+ |
|
63 |
+ def receive(incoming_events) |
|
64 |
+ return super unless events_order |
|
65 |
+ # incoming events should be processed sequentially |
|
66 |
+ incoming_events.each do |event| |
|
67 |
+ sorting_events do |
|
68 |
+ super([event]) |
|
69 |
+ end |
|
70 |
+ end |
|
71 |
+ end |
|
72 |
+ |
|
73 |
+ def create_event(event) |
|
74 |
+ if @sortable_events |
|
75 |
+ event = build_event(event) |
|
76 |
+ @sortable_events << event |
|
77 |
+ event |
|
78 |
+ else |
|
79 |
+ super |
|
80 |
+ end |
|
81 |
+ end |
|
82 |
+ |
|
83 |
+ private |
|
84 |
+ |
|
85 |
+ def sorting_events(&block) |
|
86 |
+ @sortable_events = [] |
|
87 |
+ yield |
|
88 |
+ ensure |
|
89 |
+ events, @sortable_events = @sortable_events, nil |
|
90 |
+ sort_events(events).each do |event| |
|
91 |
+ create_event(event) |
|
92 |
+ end |
|
93 |
+ end |
|
94 |
+ end |
|
95 |
+ |
|
96 |
+ private |
|
97 |
+ |
|
98 |
+ EXPRESSION_PARSER = { |
|
99 |
+ 'string' => ->string { string }, |
|
100 |
+ 'number' => ->string { string.to_f }, |
|
101 |
+ 'time' => ->string { Time.zone.parse(string) }, |
|
102 |
+ } |
|
103 |
+ EXPRESSION_TYPES = EXPRESSION_PARSER.keys.freeze |
|
104 |
+ |
|
105 |
+ def validate_events_order |
|
106 |
+ case order_by = events_order |
|
107 |
+ when nil |
|
108 |
+ when Array |
|
109 |
+ # Each tuple may be either [expression, type, desc] or just |
|
110 |
+ # expression. |
|
111 |
+ order_by.each do |expression, type, desc| |
|
112 |
+ case expression |
|
113 |
+ when String |
|
114 |
+ # ok |
|
115 |
+ else |
|
116 |
+ errors.add(:base, "first element of each events_order tuple must be a Liquid template") |
|
117 |
+ break |
|
118 |
+ end |
|
119 |
+ case type |
|
120 |
+ when nil, *EXPRESSION_TYPES |
|
121 |
+ # ok |
|
122 |
+ else |
|
123 |
+ errors.add(:base, "second element of each events_order tuple must be #{EXPRESSION_TYPES.to_sentence(last_word_connector: ' or ')}") |
|
124 |
+ break |
|
125 |
+ end |
|
126 |
+ if !desc.nil? && boolify(desc).nil? |
|
127 |
+ errors.add(:base, "third element of each events_order tuple must be a boolean value") |
|
128 |
+ break |
|
129 |
+ end |
|
130 |
+ end |
|
131 |
+ else |
|
132 |
+ errors.add(:base, "events_order must be an array of arrays") |
|
133 |
+ end |
|
134 |
+ end |
|
135 |
+ |
|
136 |
+ # Sort given events in order specified by the "events_order" option |
|
137 |
+ def sort_events(events) |
|
138 |
+ order_by = events_order.presence or |
|
139 |
+ return events |
|
140 |
+ |
|
141 |
+ orders = order_by.map { |_, _, desc = false| boolify(desc) } |
|
142 |
+ |
|
143 |
+ Utils.sort_tuples!( |
|
144 |
+ events.map.with_index { |event, index| |
|
145 |
+ interpolate_with(event) { |
|
146 |
+ interpolation_context['_index_'] = index |
|
147 |
+ order_by.map { |expression, type, _| |
|
148 |
+ string = interpolate_string(expression) |
|
149 |
+ begin |
|
150 |
+ EXPRESSION_PARSER[type || 'string'.freeze][string] |
|
151 |
+ rescue |
|
152 |
+ error "Cannot parse #{string.inspect} as #{type}; treating it as string" |
|
153 |
+ string |
|
154 |
+ end |
|
155 |
+ } |
|
156 |
+ } << index << event # index is to make sorting stable |
|
157 |
+ }, |
|
158 |
+ orders |
|
159 |
+ ).collect!(&:last) |
|
160 |
+ end |
|
161 |
+ |
|
162 |
+ # The emulation of Module#prepend provided by lib/prepend.rb does |
|
163 |
+ # not work for methods defined after a call of prepend. |
|
164 |
+ if Module.method(:prepend).source_location |
|
165 |
+ module ClassMethods |
|
166 |
+ def can_order_created_events! |
|
167 |
+ raise if cannot_create_events? |
|
168 |
+ @can_order_created_events = true |
|
169 |
+ end |
|
170 |
+ |
|
171 |
+ def can_order_created_events? |
|
172 |
+ !!@can_order_created_events |
|
173 |
+ end |
|
174 |
+ end |
|
175 |
+ |
|
176 |
+ def initialize(*args) |
|
177 |
+ if self.class.instance_variable_get(:@can_order_created_events) |
|
178 |
+ self.class.__send__ :prepend, SortableEvents::AutomaticSorter |
|
179 |
+ end |
|
180 |
+ super |
|
181 |
+ end |
|
182 |
+ end |
|
183 |
+end |
@@ -13,6 +13,7 @@ class Agent < ActiveRecord::Base |
||
13 | 13 |
include HasGuid |
14 | 14 |
include LiquidDroppable |
15 | 15 |
include DryRunnable |
16 |
+ include SortableEvents |
|
16 | 17 |
|
17 | 18 |
markdown_class_attributes :description, :event_description |
18 | 19 |
|
@@ -104,12 +105,18 @@ class Agent < ActiveRecord::Base |
||
104 | 105 |
raise "Implement me in your subclass" |
105 | 106 |
end |
106 | 107 |
|
107 |
- def create_event(attrs) |
|
108 |
+ def build_event(event) |
|
109 |
+ event = events.build(event) if event.is_a?(Hash) |
|
110 |
+ event.user = user |
|
111 |
+ event.expires_at ||= new_event_expiration_date |
|
112 |
+ event |
|
113 |
+ end |
|
114 |
+ |
|
115 |
+ def create_event(event) |
|
108 | 116 |
if can_create_events? |
109 |
- events.create!({ |
|
110 |
- :user => user, |
|
111 |
- :expires_at => new_event_expiration_date |
|
112 |
- }.merge(attrs)) |
|
117 |
+ event = build_event(event) |
|
118 |
+ event.save! |
|
119 |
+ event |
|
113 | 120 |
else |
114 | 121 |
error "This Agent cannot create events!" |
115 | 122 |
end |
@@ -40,11 +40,15 @@ module Agents |
||
40 | 40 |
"_contents": "tag contents (can be an object for nesting)" |
41 | 41 |
} |
42 | 42 |
|
43 |
+ # Ordering events in the output |
|
44 |
+ |
|
45 |
+ #{description_events_order('events in the output')} |
|
46 |
+ |
|
43 | 47 |
# Liquid Templating |
44 | 48 |
|
45 | 49 |
In Liquid templating, the following variable is available: |
46 | 50 |
|
47 |
- * `events`: An array of events being output, sorted in descending order up to `events_to_show` in number. For example, if source events contain a site title in the `site_title` key, you can refer to it in `template.title` by putting `{{events.first.site_title}}`. |
|
51 |
+ * `events`: An array of events being output, sorted in the given order, up to `events_to_show` in number. For example, if source events contain a site title in the `site_title` key, you can refer to it in `template.title` by putting `{{events.first.site_title}}`. |
|
48 | 52 |
|
49 | 53 |
MD |
50 | 54 |
end |
@@ -134,7 +138,7 @@ module Agents |
||
134 | 138 |
end |
135 | 139 |
end |
136 | 140 |
|
137 |
- source_events = received_events.order(id: :desc).limit(events_to_show).to_a |
|
141 |
+ source_events = sort_events(received_events.order(id: :desc).limit(events_to_show).to_a) |
|
138 | 142 |
|
139 | 143 |
interpolation_context.stack do |
140 | 144 |
interpolation_context['events'] = source_events |
@@ -6,6 +6,7 @@ module Agents |
||
6 | 6 |
include WebRequestConcern |
7 | 7 |
|
8 | 8 |
can_dry_run! |
9 |
+ can_order_created_events! |
|
9 | 10 |
|
10 | 11 |
default_schedule "every_12h" |
11 | 12 |
|
@@ -105,6 +106,10 @@ module Agents |
||
105 | 106 |
* `status`: HTTP status as integer. (Almost always 200) |
106 | 107 |
|
107 | 108 |
* `headers`: Response headers; for example, `{{ _response_.headers.Content-Type }}` expands to the value of the Content-Type header. Keys are insensitive to cases and -/_. |
109 |
+ |
|
110 |
+ # Ordering Events |
|
111 |
+ |
|
112 |
+ #{description_events_order} |
|
108 | 113 |
MD |
109 | 114 |
|
110 | 115 |
event_description do |
@@ -79,4 +79,44 @@ module Utils |
||
79 | 79 |
def self.pretty_jsonify(thing) |
80 | 80 |
JSON.pretty_generate(thing).gsub('</', '<\/') |
81 | 81 |
end |
82 |
+ |
|
83 |
+ class TupleSorter |
|
84 |
+ class SortableTuple |
|
85 |
+ attr_reader :array |
|
86 |
+ |
|
87 |
+ # The <=> method will call orders[n] to determine if the nth element |
|
88 |
+ # should be compared in descending order. |
|
89 |
+ def initialize(array, orders = []) |
|
90 |
+ @array = array |
|
91 |
+ @orders = orders |
|
92 |
+ end |
|
93 |
+ |
|
94 |
+ def <=> other |
|
95 |
+ other = other.array |
|
96 |
+ @array.each_with_index do |e, i| |
|
97 |
+ case cmp = e <=> other[i] |
|
98 |
+ when nil |
|
99 |
+ return nil |
|
100 |
+ when 0 |
|
101 |
+ next |
|
102 |
+ else |
|
103 |
+ return @orders[i] ? -cmp : cmp |
|
104 |
+ end |
|
105 |
+ end |
|
106 |
+ 0 |
|
107 |
+ end |
|
108 |
+ end |
|
109 |
+ |
|
110 |
+ class << self |
|
111 |
+ def sort!(array, orders = []) |
|
112 |
+ array.sort_by! do |e| |
|
113 |
+ SortableTuple.new(e, orders) |
|
114 |
+ end |
|
115 |
+ end |
|
116 |
+ end |
|
117 |
+ end |
|
118 |
+ |
|
119 |
+ def self.sort_tuples!(array, orders = []) |
|
120 |
+ TupleSorter.sort!(array, orders) |
|
121 |
+ end |
|
82 | 122 |
end |
@@ -0,0 +1,264 @@ |
||
1 |
+require 'spec_helper' |
|
2 |
+ |
|
3 |
+describe SortableEvents do |
|
4 |
+ let(:agent_class) { |
|
5 |
+ Class.new(Agent) do |
|
6 |
+ include SortableEvents |
|
7 |
+ |
|
8 |
+ default_schedule 'never' |
|
9 |
+ |
|
10 |
+ def self.valid_type?(name) |
|
11 |
+ true |
|
12 |
+ end |
|
13 |
+ end |
|
14 |
+ } |
|
15 |
+ |
|
16 |
+ def new_agent(events_order = nil) |
|
17 |
+ options = {} |
|
18 |
+ options['events_order'] = events_order if events_order |
|
19 |
+ agent_class.new(name: 'test', options: options) { |agent| |
|
20 |
+ agent.user = users(:bob) |
|
21 |
+ } |
|
22 |
+ end |
|
23 |
+ |
|
24 |
+ describe 'validations' do |
|
25 |
+ let(:agent_class) { |
|
26 |
+ Class.new(Agent) do |
|
27 |
+ include SortableEvents |
|
28 |
+ |
|
29 |
+ default_schedule 'never' |
|
30 |
+ |
|
31 |
+ def self.valid_type?(name) |
|
32 |
+ true |
|
33 |
+ end |
|
34 |
+ end |
|
35 |
+ } |
|
36 |
+ |
|
37 |
+ def new_agent(events_order = nil) |
|
38 |
+ options = {} |
|
39 |
+ options['events_order'] = events_order if events_order |
|
40 |
+ agent_class.new(name: 'test', options: options) { |agent| |
|
41 |
+ agent.user = users(:bob) |
|
42 |
+ } |
|
43 |
+ end |
|
44 |
+ |
|
45 |
+ it 'should allow events_order to be unspecified, null or an empty array' do |
|
46 |
+ expect(new_agent()).to be_valid |
|
47 |
+ expect(new_agent(nil)).to be_valid |
|
48 |
+ expect(new_agent([])).to be_valid |
|
49 |
+ end |
|
50 |
+ |
|
51 |
+ it 'should not allow events_order to be a non-array object' do |
|
52 |
+ agent = new_agent(0) |
|
53 |
+ expect(agent).not_to be_valid |
|
54 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
55 |
+ |
|
56 |
+ agent = new_agent('') |
|
57 |
+ expect(agent).not_to be_valid |
|
58 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
59 |
+ |
|
60 |
+ agent = new_agent({}) |
|
61 |
+ expect(agent).not_to be_valid |
|
62 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
63 |
+ end |
|
64 |
+ |
|
65 |
+ it 'should not allow events_order to be an array containing unexpected objects' do |
|
66 |
+ agent = new_agent(['{{key}}', 1]) |
|
67 |
+ expect(agent).not_to be_valid |
|
68 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
69 |
+ |
|
70 |
+ agent = new_agent(['{{key1}}', ['{{key2}}', 'unknown']]) |
|
71 |
+ expect(agent).not_to be_valid |
|
72 |
+ expect(agent.errors[:base]).to include(/events_order/) |
|
73 |
+ end |
|
74 |
+ |
|
75 |
+ it 'should allow events_order to be an array containing strings and valid tuples' do |
|
76 |
+ agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number']]) |
|
77 |
+ expect(agent).to be_valid |
|
78 |
+ |
|
79 |
+ agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number'], ['{{key4}}', 'time', true]]) |
|
80 |
+ expect(agent).to be_valid |
|
81 |
+ end |
|
82 |
+ end |
|
83 |
+ |
|
84 |
+ describe 'sort_events' do |
|
85 |
+ let(:payloads) { |
|
86 |
+ [ |
|
87 |
+ { 'title' => 'TitleA', 'score' => 4, 'updated_on' => '7 Jul 2015' }, |
|
88 |
+ { 'title' => 'TitleB', 'score' => 2, 'updated_on' => '25 Jun 2014' }, |
|
89 |
+ { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' }, |
|
90 |
+ { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' }, |
|
91 |
+ ] |
|
92 |
+ } |
|
93 |
+ |
|
94 |
+ let(:events) { |
|
95 |
+ payloads.map { |payload| Event.new(payload: payload) } |
|
96 |
+ } |
|
97 |
+ |
|
98 |
+ it 'should sort events by a given key' do |
|
99 |
+ agent = new_agent(['{{title}}']) |
|
100 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleC TitleD]) |
|
101 |
+ |
|
102 |
+ agent = new_agent([['{{title}}', 'string', true]]) |
|
103 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleD TitleC TitleB TitleA]) |
|
104 |
+ end |
|
105 |
+ |
|
106 |
+ it 'should sort events by multiple keys' do |
|
107 |
+ agent = new_agent([['{{score}}', 'number'], '{{title}}']) |
|
108 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleC TitleD]) |
|
109 |
+ |
|
110 |
+ agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]]) |
|
111 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC]) |
|
112 |
+ end |
|
113 |
+ |
|
114 |
+ it 'should sort events by time' do |
|
115 |
+ agent = new_agent([['{{updated_on}}', 'time']]) |
|
116 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleD TitleC TitleA]) |
|
117 |
+ end |
|
118 |
+ |
|
119 |
+ it 'should sort events stably' do |
|
120 |
+ agent = new_agent(['<constant>']) |
|
121 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC]) |
|
122 |
+ |
|
123 |
+ agent = new_agent([['<constant>', 'string', true]]) |
|
124 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC]) |
|
125 |
+ end |
|
126 |
+ |
|
127 |
+ it 'should support _index_' do |
|
128 |
+ agent = new_agent([['{{_index_}}', 'number', true]]) |
|
129 |
+ expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleC TitleD TitleB TitleA]) |
|
130 |
+ end |
|
131 |
+ end |
|
132 |
+ |
|
133 |
+ describe 'automatic event sorter' do |
|
134 |
+ describe 'declaration' do |
|
135 |
+ let(:passive_agent_class) { |
|
136 |
+ Class.new(Agent) do |
|
137 |
+ include SortableEvents |
|
138 |
+ |
|
139 |
+ cannot_create_events! |
|
140 |
+ end |
|
141 |
+ } |
|
142 |
+ |
|
143 |
+ let(:active_agent_class) { |
|
144 |
+ Class.new(Agent) do |
|
145 |
+ include SortableEvents |
|
146 |
+ end |
|
147 |
+ } |
|
148 |
+ |
|
149 |
+ describe 'can_order_created_events!' do |
|
150 |
+ it 'should refuse to work if called from an Agent that cannot create events' do |
|
151 |
+ expect { |
|
152 |
+ passive_agent_class.class_eval do |
|
153 |
+ can_order_created_events! |
|
154 |
+ end |
|
155 |
+ }.to raise_error |
|
156 |
+ end |
|
157 |
+ |
|
158 |
+ it 'should work if called from an Agent that can create events' do |
|
159 |
+ expect { |
|
160 |
+ active_agent_class.class_eval do |
|
161 |
+ can_order_created_events! |
|
162 |
+ end |
|
163 |
+ }.not_to raise_error |
|
164 |
+ end |
|
165 |
+ end |
|
166 |
+ |
|
167 |
+ describe 'can_order_created_events?' do |
|
168 |
+ it 'should return false unless an Agent declares can_order_created_events!' do |
|
169 |
+ expect(active_agent_class.can_order_created_events?).to eq(false) |
|
170 |
+ expect(active_agent_class.new.can_order_created_events?).to eq(false) |
|
171 |
+ end |
|
172 |
+ |
|
173 |
+ it 'should return true if an Agent declares can_order_created_events!' do |
|
174 |
+ active_agent_class.class_eval do |
|
175 |
+ can_order_created_events! |
|
176 |
+ end |
|
177 |
+ |
|
178 |
+ expect(active_agent_class.can_order_created_events?).to eq(true) |
|
179 |
+ expect(active_agent_class.new.can_order_created_events?).to eq(true) |
|
180 |
+ end |
|
181 |
+ end |
|
182 |
+ end |
|
183 |
+ |
|
184 |
+ describe 'behavior' do |
|
185 |
+ class Agents::EventOrderableAgent < Agent |
|
186 |
+ include SortableEvents |
|
187 |
+ |
|
188 |
+ default_schedule 'never' |
|
189 |
+ |
|
190 |
+ can_order_created_events! |
|
191 |
+ |
|
192 |
+ attr_accessor :payloads_to_emit |
|
193 |
+ |
|
194 |
+ def self.valid_type?(name) |
|
195 |
+ true |
|
196 |
+ end |
|
197 |
+ |
|
198 |
+ def check |
|
199 |
+ payloads_to_emit.each do |payload| |
|
200 |
+ create_event payload: payload |
|
201 |
+ end |
|
202 |
+ end |
|
203 |
+ |
|
204 |
+ def receive(events) |
|
205 |
+ events.each do |event| |
|
206 |
+ payloads_to_emit.each do |payload| |
|
207 |
+ create_event payload: payload.merge('title' => payload['title'] + event.payload['title_suffix']) |
|
208 |
+ end |
|
209 |
+ end |
|
210 |
+ end |
|
211 |
+ end |
|
212 |
+ |
|
213 |
+ def new_agent(events_order = nil) |
|
214 |
+ options = {} |
|
215 |
+ options['events_order'] = events_order if events_order |
|
216 |
+ Agents::EventOrderableAgent.new(name: 'test', options: options) { |agent| |
|
217 |
+ agent.user = users(:bob) |
|
218 |
+ agent.payloads_to_emit = payloads |
|
219 |
+ } |
|
220 |
+ end |
|
221 |
+ |
|
222 |
+ let(:payloads) { |
|
223 |
+ [ |
|
224 |
+ { 'title' => 'TitleA', 'score' => 4, 'updated_on' => '7 Jul 2015' }, |
|
225 |
+ { 'title' => 'TitleB', 'score' => 2, 'updated_on' => '25 Jun 2014' }, |
|
226 |
+ { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' }, |
|
227 |
+ { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' }, |
|
228 |
+ ] |
|
229 |
+ } |
|
230 |
+ |
|
231 |
+ it 'should keep the order of created events unless events_order is specified' do |
|
232 |
+ [[], [nil], [[]]].each do |args| |
|
233 |
+ agent = new_agent(*args) |
|
234 |
+ agent.save! |
|
235 |
+ expect { agent.check }.to change { Event.count }.by(4) |
|
236 |
+ events = agent.events.last(4).sort_by(&:id) |
|
237 |
+ expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC]) |
|
238 |
+ end |
|
239 |
+ end |
|
240 |
+ |
|
241 |
+ it 'should sort events created in check() in the order specified in events_order' do |
|
242 |
+ agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]]) |
|
243 |
+ agent.save! |
|
244 |
+ expect { agent.check }.to change { Event.count }.by(4) |
|
245 |
+ events = agent.events.last(4).sort_by(&:id) |
|
246 |
+ expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC]) |
|
247 |
+ end |
|
248 |
+ |
|
249 |
+ it 'should sort events created in receive() in the order specified in events_order' do |
|
250 |
+ agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]]) |
|
251 |
+ agent.save! |
|
252 |
+ expect { |
|
253 |
+ agent.receive([Event.new(payload: { 'title_suffix' => ' [new]' }), |
|
254 |
+ Event.new(payload: { 'title_suffix' => ' [popular]' })]) |
|
255 |
+ }.to change { Event.count }.by(8) |
|
256 |
+ events = agent.events.last(8).sort_by(&:id) |
|
257 |
+ expect(events.map { |event| event.payload['title'] }).to eq([ |
|
258 |
+ 'TitleB [new]', 'TitleA [new]', 'TitleD [new]', 'TitleC [new]', |
|
259 |
+ 'TitleB [popular]', 'TitleA [popular]', 'TitleD [popular]', 'TitleC [popular]', |
|
260 |
+ ]) |
|
261 |
+ end |
|
262 |
+ end |
|
263 |
+ end |
|
264 |
+end |
@@ -114,4 +114,44 @@ describe Utils do |
||
114 | 114 |
expect(cleaned_json).to include("<\\/script>") |
115 | 115 |
end |
116 | 116 |
end |
117 |
+ |
|
118 |
+ describe "#sort_tuples!" do |
|
119 |
+ let(:tuples) { |
|
120 |
+ time = Time.now |
|
121 |
+ [ |
|
122 |
+ [2, "a", time - 1], # 0 |
|
123 |
+ [2, "b", time - 1], # 1 |
|
124 |
+ [1, "b", time - 1], # 2 |
|
125 |
+ [1, "b", time], # 3 |
|
126 |
+ [1, "a", time], # 4 |
|
127 |
+ [2, "a", time + 1], # 5 |
|
128 |
+ [2, "a", time], # 6 |
|
129 |
+ ] |
|
130 |
+ } |
|
131 |
+ |
|
132 |
+ it "sorts tuples like arrays by default" do |
|
133 |
+ expected = tuples.values_at(4, 2, 3, 0, 6, 5, 1) |
|
134 |
+ |
|
135 |
+ Utils.sort_tuples!(tuples) |
|
136 |
+ expect(tuples).to eq expected |
|
137 |
+ end |
|
138 |
+ |
|
139 |
+ it "sorts tuples in order specified: case 1" do |
|
140 |
+ # order by x1 asc, x2 desc, c3 asc |
|
141 |
+ orders = [false, true, false] |
|
142 |
+ expected = tuples.values_at(2, 3, 4, 1, 0, 6, 5) |
|
143 |
+ |
|
144 |
+ Utils.sort_tuples!(tuples, orders) |
|
145 |
+ expect(tuples).to eq expected |
|
146 |
+ end |
|
147 |
+ |
|
148 |
+ it "sorts tuples in order specified: case 2" do |
|
149 |
+ # order by x1 desc, x2 asc, c3 desc |
|
150 |
+ orders = [true, false, true] |
|
151 |
+ expected = tuples.values_at(5, 6, 0, 1, 4, 3, 2) |
|
152 |
+ |
|
153 |
+ Utils.sort_tuples!(tuples, orders) |
|
154 |
+ expect(tuples).to eq expected |
|
155 |
+ end |
|
156 |
+ end |
|
117 | 157 |
end |
@@ -209,6 +209,22 @@ describe Agents::DataOutputAgent do |
||
209 | 209 |
}) |
210 | 210 |
end |
211 | 211 |
|
212 |
+ describe 'ordering' do |
|
213 |
+ before do |
|
214 |
+ agent.options['events_order'] = ['{{title}}'] |
|
215 |
+ end |
|
216 |
+ |
|
217 |
+ it 'can reorder the events_to_show last events based on a Liquid expression' do |
|
218 |
+ asc_content, _status, _content_type = agent.receive_web_request({ 'secret' => 'secret2' }, 'get', 'application/json') |
|
219 |
+ expect(asc_content['items'].map {|i| i["title"] }).to eq(["Evolving", "Evolving again", "Evolving yet again with a past date"]) |
|
220 |
+ |
|
221 |
+ agent.options['events_order'] = [['{{title}}', 'string', true]] |
|
222 |
+ |
|
223 |
+ desc_content, _status, _content_type = agent.receive_web_request({ 'secret' => 'secret2' }, 'get', 'application/json') |
|
224 |
+ expect(desc_content['items']).to eq(asc_content['items'].reverse) |
|
225 |
+ end |
|
226 |
+ end |
|
227 |
+ |
|
212 | 228 |
describe "interpolating \"events\"" do |
213 | 229 |
before do |
214 | 230 |
agent.options['template']['title'] = "XKCD comics as a feed{% if events.first.site_title %} ({{events.first.site_title}}){% endif %}" |